First step is to make sure that the SwapPricer package is able to price the contracts we want to focus on (ie. USD fixed-float swaps).
I have loaded in the Data folder a set of documents I was able to download from Bloomberg.
Let’s setup the run:
# Remove the comment once installed
# remotes::install_github("DavideMagno/SwapPricer")
portfolio <- tibble::tribble(
~ID, ~currency, ~notional, ~start.date, ~maturity.date, ~strike, ~type, ~standard,
"ATM", "USD", 80000000, "18-06-2021", "18-06-2031", 0.01396766, "receiver", TRUE,
"OTM", "USD", 80000000, "18-06-2021", "18-06-2031", 0.01000000, "receiver", TRUE
)
curve <- readxl::read_excel(here::here("Data/SwapPricing/Curve.xlsx")) |>
dplyr::select(Date = `Maturity Date`, df = Discount) |>
dplyr::mutate(Date = as.Date(Date, "%m/%d/%Y")) |>
tibble::add_row(Date = as.Date("2021-06-18"), df = 1, .before = 1)
Some R technical notes:
I use the latest version of R (4.1.0) which has recently introduced a native pipe operator (you can read more about it here). Let me know if you can’t manage to update it (OK I’ve updated my desktop version to R-4.1.0 and updated RStudio too)
I always use the notation package::function because a) typically it is the most common way of coding for packages and b) it removes any ambiguity on whether a function is native R or belongs to a package. A bit more verbose but it goes well with the piping
I use the here package because it allows not to reset any directory when two (or more) people work on the same file on two (or more) different computers
usd.curve <- list()
usd.curve$currency <- "USD"
usd.curve$discount <- curve
LiborRate <- readr::read_csv(here::here("Data/LiborRate.csv"))
##
## ── Column specification ────────────────────────────────────────────────────────
## cols(
## DATE = col_date(format = ""),
## USD3MTD156N = col_character()
## )
pricing.day <- as.Date("2021-06-18")
SwapPricer::SwapPortfolioPricing(portfolio, pricing.day, usd.curve, LiborRate)
We compare the pricing with the screenshots from Bloomberg, starting from the ATM one:
The difference in pricing is pretty close: $27k of difference in MTM which is almost 0.35 times the pv01. Difference in pv01 is $900
Let’s see the ITM swap
Difference in pricing has increased to $60k, which is still less than 1 pv01 (0.79)
Pricing is therefore in line with Bloomberg and we can use it for estimates
Let’s start the analysis by loading the file
date <- "2021_06_18"
# I use the readr package to read csvs because it doesn't assume that strings are factors by default
IRTrade <- readr::read_csv(here::here(paste0("Data/DTCC",date,".csv")))
As agreed, let’s start focusing only on:
swaps <- IRTrade |>
dplyr::mutate(`Notional Amount 1` = stringr::str_remove_all(`Notional Amount 1`, "\\,") |>
stringr::str_remove("\\+") |>
as.numeric(),
time.to.mat = (`Expiration Date` - `Effective Date`)/365) |>
dplyr::filter(`Product ID` == "InterestRate:IRSwap:FixedFloat",
Action == "NEW",
`Transaction Type` == "Trade",
`Notional Currency 1` == "USD",
`Payment Frequency Period 1` %in% c("3M", "6M"),
`Payment Frequency Period 2` %in% c("3M", "6M"),
Cleared == "C",
`Block Trade Election Indicator` == "N",
is.na(`Other Payment Amount`),
grepl("N",`Non-Standardized Pricing Indicator`))
We now have to put the swaps information in the right format for the SwapPricer:
swap.DTCC.portfolio <- swaps |>
dplyr::summarise(ID = `Dissemination ID`,
currency = `Notional Currency 1`,
notional = `Notional Amount 1` |>
stringr::str_replace_all(",","") |>
as.numeric(),
start.date = as.character(`Effective Date`, format = "%d/%m/%Y"),
maturity.date = as.character(`Expiration Date`, format = "%d/%m/%Y"),
strike = dplyr::if_else(
is.na(`Fixed Rate 1`),`Fixed Rate 2`, `Fixed Rate 1`),
type = dplyr::if_else(
is.na(`Fixed Rate 1`), "payer", "receiver"),
standard = TRUE,
time.to.mat = time.to.mat)
and price them using the swap pricer.
priced.DTCC.portfolio <- SwapPricer::SwapPortfolioPricing(swap.DTCC.portfolio, pricing.day, usd.curve, LiborRate)
priced.DTCC.portfolio
The pricing allows us to infer some interesting information on the swaps traded on the day:
not.priced.ids <- priced.DTCC.portfolio |>
dplyr::filter(is.na(clean.mv)) |>
dplyr::pull(swap.id)
swap.DTCC.portfolio |>
dplyr::filter(ID %in% not.priced.ids) |>
dplyr::mutate(time.to.mat = (lubridate::dmy(maturity.date) - lubridate::dmy(start.date))) |>
dplyr::select(ID, start.date, maturity.date, time.to.mat) |>
dplyr::count(time.to.mat)
We can see that the swaps not priced have a time to maturity of around 3 months. We can then remove them from the analysis as they are actually FRAs rather than swaps.
`%notin%` <- Negate(`%in%`)
priced.DTCC.portfolio <- priced.DTCC.portfolio |>
dplyr::filter(swap.id %notin% not.priced.ids)
swap.DTCC.portfolio <- swap.DTCC.portfolio |>
dplyr::filter(ID %notin% not.priced.ids)
priced.DTCC.portfolio |>
dplyr::summarise_if(is.numeric, sum) |>
dplyr::select(-par) |>
dplyr::mutate(deviation = clean.mv/pv01)
We can therefore analyse the fixed rates in the DTCC file (black dots), vs the swap curve from BBG that we used for pricing (red dots - we only included actual swap rates, without extrapolating them from the middle part of the curve made of swaptions)
We can notice that: - there is a significantly wide range of strikes traded especially on the 5, 10 and 30 years maturity. Are these out of the money trades or do intra-day movements justify these deviations? Let’s analyse the dynamic of the intraday rate from Bloomberg: The intra-day rates have been particularly volatile with an average of 12bps of decrease from the intraday. This justifies the fact that the red dots are at the lower bottom of the range. Let’s repeat the analysis removing all the trades that are at max 12 bps above the swap rate used for pricing:
From the analysis, the number of trades that have been struck outside the range of reasonable levels of moneyness is 169 out of 1668.
Let’s analyse the pricing to see where most of the differences come from:
non.atm.ids <- strike.table |>
dplyr::select(ID) |>
dplyr::mutate(ID = as.character(ID),
group = "Not ATM")
priced.DTCC.portfolio |>
dplyr::left_join(non.atm.ids, by = c("swap.id" = "ID")) |>
dplyr::mutate(group = dplyr::if_else(is.na(group), "ATM", "Not ATM")) |>
dplyr::group_by(group) |>
dplyr::summarise_if(is.numeric, sum) |>
dplyr::select(-par) |>
dplyr::mutate(deviation = clean.mv/pv01)
As expected, most of the deviation comes from the swaps that are outside of the 12bps intra-day range we have identified. Still, the deviation in terms of pv01 is not in the space of 1 (or lower, if possible) which means that intraday data does matter when we look at the DTCC data and we price it.
Why have there been so many trades with strikes far from the ATM market quotes? Analyzing these trade we can say that a lot depends on the start date of the swaps. Considering the convention of “D+2” a spot starting swap traded on the 18th would have an effective date of the 22nd. We see if there are swaps that are not spot starting.
strike.table <- strike.table |>
dplyr::mutate(start.date = lubridate::dmy(start.date)) |>
dplyr::mutate(type = dplyr::case_when(
start.date > as.Date("2021-06-22") ~ "Forward starting",
start.date < as.Date("2021-06-22") ~ "Initiated before the 22nd of June",
TRUE ~ "Spot starting"
))
strike.table |>
dplyr::count(type)
We can hence conclude that the vast majority of swaps that have a par rate not in line with the intraday movements of the spot swap rates are forward starting.
Let’s for now limit the analysis to spot starting swaps and calculate the duration for each of the swaps:
strike.table <- strike.table |>
dplyr::filter(type %notin% "Spot starting")
duration.table <- swap.DTCC.portfolio |>
dplyr::filter(ID %notin% strike.table$ID) |>
SwapPricer::SwapPortfolioPricing(pricing.day, usd.curve, LiborRate, duration.flag = TRUE) |>
dplyr::select(swap.id, notional, clean.mv, pv01, duration)
From a coding perspective we just note that the formula is the same used above but we included a duration.flag = TRUE to indicate that we want to calculate duration as well.
We are finally in a position of calculating a notional weighted average duration traded:
duration.table |>
with(sum(notional * duration)/sum(notional))
## [1] 6.229371
Another interesting analysis is to see whether there is difference in duration traded between payer and receiver swaps:
ids.side <- swap.DTCC.portfolio |>
dplyr::select(swap.id = ID, type) |>
dplyr::mutate(swap.id = as.character(swap.id))
duration.table |>
dplyr::left_join(ids.side, by = "swap.id") |>
dplyr::group_by(type) |>
dplyr::summarise(`Total Notional` = sum(notional),
`Average Duration` = sum(notional * duration)/sum(notional))
We notice no significant differences between the two sides of the trade (less than 1 year) but the vast majority of the notional is on one direction.
We can now look at the distribution of the duration traded by binning the contracts by duration
bucket.table <- duration.table |>
dplyr::mutate(Bucket = cut(
duration,
breaks = c(0, 1, 3, 4, 5, 7, 10, 15, 20, 25, 30, 40, 50, 100),
labels = c("0-1", "1-3", "3-4", "4-5", "5-7", "7-10", "10-15", "15-20", "20-25", "25-30", "30-40", "40-50", "50-100" ),
right = FALSE))
p <- bucket.table |>
dplyr::group_by(Bucket) |>
dplyr::summarise(Notional = sum(notional)) |>
ggplot(aes(x = Bucket, y = Notional, text = sprintf("Notional: %s", scales::comma(Notional)))) +
geom_col() +
theme_bw() +
labs(x = "Buckets", y = "Notional Traded") +
scale_y_continuous(labels = scales::label_number(suffix = "bn", scale = 1e-9))
fig <- plotly::ggplotly(p, tooltip=c("Bucket", "text"))
fig
Instead of plotting the distribution of the duration by notional traded, we can look at the number of trades:
p <- bucket.table |>
ggplot(aes(x = Bucket)) +
geom_bar() +
theme_bw() +
labs(x = "Buckets", y = "Number of trades")
fig <- plotly::ggplotly(p)
fig
It is interesting to see that in the buckets 1-3 and 4-5 there are few trades but with higher notional. This means that there is someone on the market trying to have a decent risk exposure in the short term part of the curve increasing the notional of the trades. We can summarise the information coming from both these graphs looking at the pv01 by bucket.
p <- bucket.table |>
dplyr::group_by(Bucket) |>
dplyr::summarise(pv01 = sum(pv01)) |>
ggplot(aes(x = Bucket, y = pv01, text = sprintf("PV01: %s", scales::comma(pv01)))) +
geom_col() +
theme_bw() +
labs(x = "Buckets", y = "PV01 Traded") +
scale_y_continuous(labels = scales::label_number(suffix = "m", scale = 1e-6))
fig <- plotly::ggplotly(p, tooltip=c("Bucket", "text"))
fig
We can finally observe that the market is mostly trading risk in the 7-10 bucket and that it is doing it quite consistently in one direction but we don’t know exactly if the market is shorting or going long the risk, because the DTCC data doesn’t say whether the “Leg 1” is payer or receiver. For pricing purposes we had to choose a side. We need to further “read through” the data to see if we can deduct this information from it.